Перейти к основному содержимому

Основы языка Rust

Разработчику Архитектору

Основы языка Rust

Rust как ответ на системные вызовы современной инженерии

Rust — это язык системного программирования, разрабатываемый с 2006 года Mozilla Research, а с 2021 года — независимым Rust Foundation. Цель Rust в том, чтобы предложить иную парадигму системного кода: не жертвовать ни производительностью, ни безопасностью, ни выразительностью. Триада performance–safety–productivity лежит в основе его дизайна, и именно такова его философская установка: «You can have it all», но при условии, что программист примет новую модель мышления — модель владения и заимствования.

Давайте познакомимся с простейшим примером, напишем код, который создает программу, которая выводит сообщение «Hello, world!» на экран. Он включает в себя функцию main, которая является точкой входа в любую программу на Rust.

fn main() {
println!("Hello, world!");
}

fn - ключевое слово, которое обозначает начало объявления функции. Функция представляет собой именованный блок кода, выполняющий конкретную задачу. В данном случае функция называется main.

main - имя специальной функции, которая запускается автоматически при запуске программы. Система операционная система вызывает именно эту функцию для начала выполнения логики приложения. Любая исполняемая программа на Rust должна содержать функцию с именем main.

Фигурные скобки определяют область видимости тела функции. Весь код, находящийся внутри этих скобок, выполняется последовательно по порядку следования строк.

println! - это макрос (макрос — это инструмент, расширяющийся во время компиляции), который выводит текст на стандартный поток вывода, обычно это терминал или консоль. Знак восклицания после имени указывает на то, что это макрос, а не обычная функция. Макрос обрабатывает форматирование строки перед передачей данных в систему вывода. Строка заключена в двойные кавычки, что сообщает компилятору о том, что содержимое следует воспринимать как последовательность символов текста.

Язык не является надмножеством C, не пытается эмулировать C++ или Go. Он синтезирует успешные идеи из нескольких областей:

  • низкоуровневый контроль (как в C/C++),
  • строгая статическая проверка (как в Haskell или ML),
  • современные инструменты сборки и управления зависимостями (как в Node.js или Python),
  • языковая поддержка конкурентности без гонок данных (впервые реализованная на уровне компилятора в промышленном масштабе).

Rust не позиционирует себя как универсальный «язык для всего», но как язык для критичных к надёжности и производительности компонентов, которые ранее требовали ручного управления памятью, но не должны нести на себе бремя частых уязвимостей и непредсказуемого поведения. Это отражено в его слогане: «A language empowering everyone to build reliable and efficient software».


Почему Rust был необходим

До появления Rust системное программирование разделялось на две модели:

  1. Языки без автоматического управления памятью — C и C++. Высокая производительность, полный контроль над памятью и железом, но высокая цена: уязвимости типа use-after-free, double-free, buffer overflows и Данные races составляют значительную долю CVE в ядрах ОС, сетевых стеках и криптографических библиотеках.

  2. Языки с автоматическим управлением памятью (сборщиком мусора) — Java, C#, Go. Повышается безопасность и продуктивность, однако:

    • GC вводит непредсказуемые паузы и накладные расходы по памяти;
    • неприемлем для систем без heap-менеджера или с жёсткими требованиями к latency (real-time systems, embedded, kernels);
    • FFI-взаимодействие с нативным кодом требует осторожности и часто отменяет преимущества GC.

Между этими полюсами образовался вакуум: не существовало языка, который бы обеспечивал нулевые накладные расходы (zero-cost abstractions), гарантированную безопасность памяти на этапе компиляции и полный контроль над моделью исполнения. Rust был создан как эксперимент по заполнению этой ниши.

Первый стабильный релиз (1.0) состоялся в 2015 году. С тех пор Rust демонстрирует устойчивый рост в индустриальных применениях: от ядра Linux (начиная с 6.1), через Android (частичная замена C/C++ в системных компонентах), до Microsoft (переписывание компонентов Windows на Rust) и Amazon (использование в AWS Nitro, Firecracker и S2N).


Ключевые особенности языка

Безопасность памяти без сборщика мусора

В Rust отсутствует runtime-сборка мусора. Вместо этого безопасность памяти обеспечивается статически, на этапе компиляции, с помощью трёх взаимосвязанных механизмов:

  • Владение (Ownership) — каждое значение в Rust имеет ровно одного владельца; при выходе владельца из области видимости значение автоматически освобождается.
  • Заимствование (Borrowing) — ссылки на значение могут быть иммутабельными (&T) или мутабельными (&mut T), но при соблюдении строгих правил:
    • В любой момент времени может существовать либо произвольное количество иммутабельных ссылок, либо ровно одна мутабельная;
    • Ссылки не могут «пережить» данные, на которые они ссылаются.
  • Время жизни (Lifetimes) — механизм, позволяющий компилятору проверять, что ссылки остаются валидными на всём протяжении их использования. Явные аннотации ('a) требуются только в сигнатурах функций и структур, где вывод недостаточен.

Разберём примеры.

Пример 1: Владение и автоматическое освобождение памяти. Переменная s становится владельцем строки данных. Когда переменная выходит за пределы области видимости (закрывающая фигурная скобка), компилятор автоматически вызывает функцию удаления, освобождая память.

fn main() {
// Строка "Hello" создаётся в куче, s становится её владельцем
let s = String::from("Привет, мир!");

println!("Владелец s: {}", s);

// Выход из области видимости функции main
// Компилятор автоматически генерирует вызов drop(s) здесь
}

При выполнении программы строка «Привет, мир!» остаётся в памяти до момента завершения функции main. Система гарантирует отсутствие утечек памяти, так как удаление происходит синхронно с выходом владельца из зоны действия.

Пример 2: Заимствование и правила доступа. Несколько ссылок на одно значение могут существовать одновременно, но ни одна из них не позволяет изменять данные. Попытка создать мутабельную ссылку при наличии иммутабельной вызовет ошибку компиляции.

fn main() {
let mut Данные = String::from("Исходные данные");

// Создание первой иммутабельной ссылки
let ref1 = &Данные;

// Создание второй иммутабельной ссылки
let ref2 = &Данные;

// Чтение данных через обе ссылки разрешено
println!("Данные через ref1: {}", ref1);
println!("Данные через ref2: {}", ref2);

// Попытка создать мутабельную ссылку вызовет ошибку компиляции
// Пока существуют ref1 и ref2, изменение данных запрещено
// let ref_mut = &mut Данные;

// Удаление ссылок ref1 и ref2 (они выходят из области видимости)
drop(ref1);
drop(ref2);

// Теперь создание мутабельной ссылки возможно
let ref_mut = &mut Данные;
*ref_mut = "Изменённые данные";

println!("Изменённые данные: {}", Данные);
}

Компилятор отслеживает область видимости всех ссылок. Он запрещает одновременное наличие одной мутабельной и любого количества иммутабельных ссылок, что предотвращает состояния гонки и повреждение данных.

Пример 3: Время жизни и проверка валидности. Этот код иллюстрирует, как механизм времени жизни защищает от использования ссылок после уничтожения данных, на которые они указывают. Попытка вернуть ссылку на локальную переменную приведёт к ошибке, так как ссылка переживёт свои данные.

// Функция пытается вернуть ссылку на локальную переменную x
// Это вызовет ошибку компилятора: "borrowed value does not live long enough"
fn invalid_reference() -> &String {
let x = String::from("Временные данные");
// Возврат ссылки на x недопустим, так как x будет удалён после выхода функции
// return &x;
&x
}

// Корректный вариант: возврат самого значения по владению
fn valid_ownership() -> String {
let x = String::from("Переданное владение");
// Владелец передается вызывающей стороне, память останется валидной
x
}

fn main() {
// Вызов корректной функции передаёт владение
let owned_string = valid_ownership();
println!("{}", owned_string);

// Попытка вызвать invalid_reference() приведёт к ошибке сборки
// let broken_ref = invalid_reference();
}

Механизм времени жизни анализирует структуру кода статически. Он подтверждает, что ссылка никогда не станет недействительной до того, как перестанет использоваться. Если программа проходит проверку компилятора, она гарантированно безопасна в отношении обращения к памяти.

Пример 4: Мутабельность и конфликты ссылок. Демонстрация строгого запрета на одновременное изменение и чтение одного и того же блока данных.

fn main() {
let mut numbers = vec![1, 2, 3];

// Ссылка для чтения
let read_ref = &numbers;

// Попытка получить ссылку для записи вызовет ошибку
// because a mutable reference cannot exist while an immutable one exists
// let write_ref = &mut numbers;

println!("Чтение через immutable ссылку: {:?}", read_ref);

// После выхода read_ref из области видимости можно писать
let write_ref = &mut numbers;
write_ref.push(4);

println!("После изменения: {:?}", numbers);
}

Система типов Rust обеспечивает целостность данных без необходимости использования блокировок или атомарных операций во время выполнения. Все проверки выполняются до запуска программы, что исключает runtime-ошибки, связанные с доступом к памяти.

Эта система исключает классические ошибки:

  • Use-after-free — невозможно создать ссылку, срок жизни которой превышает срок жизни данных;
  • Double-free — значение освобождается ровно один раз, когда выходит из области видимости его единственный владелец;
  • Данные races — при компиляции в многопоточном коде: невозможна одновременная запись и чтение/запись одной переменной без синхронизации.

Это формальная модель, основанная на аффинной логике и линейных типах. Компилятор доказывает корректность намерений.

Zero-cost abstractions

Rust следует принципу zero-cost abstractions: любая высокоуровневая конструкция (итераторы, замыкания, паттерн-матчинг, монадоподобные типы Option/Result) после компиляции в оптимизированный режим (--release) генерирует код, идентичный или близкий к написанному вручную на C.

Например:

  • for x in vec.iter() → компилируется в bare-pointer loop без вызовов функций;
  • map().filter().collect() → инлайнится в один цикл;
  • Result<T, E> не накладывает оверхеда по сравнению с возвратом кода ошибки в регистре.

Это позволяет писать выразительный, функционально-вдохновлённый код, не платя за него в runtime.

Выразительный и строгий типизированный язык

Rust — язык со статической, строгой, выводимой типизацией.

Типовая система включает:

  • Параметрический полиморфизм (generics);
  • Ад-хок полиморфизм через трейты (аналог интерфейсов, но с мощными расширениями: ассоциированные типы, дефолтные реализации, ограничения на lifetime);
  • Суммарные и произведение типов (enum и struct), включая алгебраические типы данных (ADT), например:
    enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
    }
    Здесь один тип объединяет разнородные варианты с данными — и компилятор гарантирует, что все варианты обработаны в match.

Особое место занимает обработка ошибок: вместо исключений (exceptions) Rust использует явную обработку через Result<T, E> и Option<T>. Это делает поток ошибок видимым на уровне типов и исключает «скрытые» пути выполнения.

Инкрементальная и надёжная компиляция

Rust использует компилятор rustc, основанный на LLVM, и систему сборки Cargo. Cargo обеспечивает:

  • Управление зависимостями с семантическим версионированием и изоляцией (crates);
  • Репродюцируемую сборку (с фиксированным Cargo.lock);
  • Встроенные средства тестирования, документации (cargo doc), проверки (cargo clippy, cargo fmt);
  • Поддержку кросс-компиляции «из коробки».

Модель crate (единица компиляции) и строгая система modules позволяют строить крупные проекты с чёткими границами видимости и инкапсуляцией — без необходимости в отдельных файлах заголовков или make-файлах.


Сфера применения

Применения, где Rust проявляет себя наилучшим образом

  • Системное программирование: ядра ОС (Redox OS, компоненты Linux), драйверы устройств, firmware (через no_std), микроконтроллеры (STM32, ESP32). Возможность отключения стандартной библиотеки (#![no_std]) позволяет работать в средах без heap и OS.

  • Инфраструктурные компоненты: сетевые серверы (Tokio, Actix), прокси (Linkerd, Envoy-подобные), базы данных (TiKV, SurrealDB), веб-асемблер (Wasmtime, WASI), криптографические библиотеки (ring, Rustls). Здесь важны предсказуемость latency и отсутствие GC-пауз.

  • Компиляторы, анализаторы, транспайлеры: благодаря мощной системе макросов (declarative и procedural), строгой типизации и инструментам вроде syn, quote, proc-macro2. Примеры: rustc сам, clippy, serde, tauri.

  • Кросс-платформенные CLI-утилиты: благодаря статической линковке (возможна), единому экзешнику и отсутствию зависимостей runtime.

  • Блокчейны и децентрализованные системы: Ethereum (Parity, Substrate), Solana, NEAR — где критична детерминированность выполнения и защита от уязвимостей.

  • Встраивание в другие языки: через FFI Rust может выступать «усилителем» для Python (PyO3), JavaScript (wasm-bindgen, Neon), Ruby (Rutie), Java (JNI). Часто используется для написания performance-critical hot paths.

Границы применимости

Rust не идеален для:

  • Быстрой прототипной разработки под задачи аналитики, ML или визуализации — здесь Python/Julia/JS остаются эффективнее по времени разработки.
  • GUI-приложений с плотной интеграцией в нативные фреймворки — хотя решения есть (egui, Slint, Tauri, Iced), экосистема уступает в зрелости C#/WPF или Swift/UIKit.
  • Веб-фронтенда без WebAssembly — JS/TS остаются стандартом; Rust здесь — инструмент для ускорения конкретных модулей.
  • Образовательных целей «с нуля» — крутая кривая обучения из-за модели владения затрудняет первые шаги; проще начинать с Python или JS.

Однако даже в этих областях Rust находит применение как компоновочный язык: например, Tauri использует Rust для бэкенда, а HTML/JS — для фронтенда; PyO3 — для ускорения compute-heavy частей в Python-библиотеках.


Экосистема и культура

Rust не ограничивается синтаксисом. Это языковая экосистема, включающая:

  • RFC-процесс — все изменения языка проходят открытую дискуссию и формальную проработку;
  • Editions — мажорные релизы каждые 3 года (2015, 2018, 2021, 2024), сохраняющие обратную совместимость, но позволяющие эволюционировать без «языкового раскола»;
  • Clippy и rustfmt — встроенные инструменты, обеспечивающие единый стиль и качество кода по умолчанию;
  • Crates.io — централизованный реестр пакетов с жёсткими ограничениями на именование и версионирование.

Культура сообщества делает ставку на ясность, надёжность и инклюзивность. Документация (The Rust Book, Rust By Example, nomicon) считается одной из лучших в индустрии. Компилятор выдаёт обучающие подсказки с примерами исправлений.


Синтаксис Rust

На первый взгляд, синтаксис Rust напоминает C/C++/Java: фигурные скобки, fn, let, if, loop, match, типы после именования переменной (x: i32). Однако это сходство поверхностно. Rust использует алгебраический и выражение-ориентированный синтаксис, в котором почти всё — выражение (expression), возвращающее значение. Это создаёт принципиально иную модель композиции кода.

Выражения и операторы

В отличие от C/C++ (где if, loop, while, matchоператоры, не возвращающие значение), в Rust:

  • if, match, loopbreak value), блоки { … }выражения;
  • let, fn, use, struct, enum, traitобъявления (items);
  • присваивание (x = y), вызовы функций без возвращаемого значения (()) — операторы, но возвращают unit-type ().

Пример:

let x = if condition {
42
} else {
0
};
// x имеет тип i32, значение зависит от ветки if

Это не просто «удобство». Это гарантия отсутствия неинициализированных переменных: переменная x инициализируется всегда, и компилятор требует, чтобы все ветки if и match возвращали значение одного типа. Это исключает целый класс ошибок uninitialised reads.

Аналогично, match не просто замена switch — это исчерпывающая проверка вариантов:

enum Message {
Quit,
Write(String),
Move { x: i32, y: i32 },
}
let msg = Message::Write("hello".into());
match msg {
Message::Quit => println!("Quitting"),
Message::Write(text) => println!("Text: {}", text),
Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
// _ => { } — запрещено: все варианты должны быть обработаны явно
}

Компилятор проверяет полноту покрытия, и если добавить новый вариант в enum, все match-выражения, не покрывающие его, перестанут компилироваться. Это делает рефакторинг безопасным — не нужно искать все case в кодовой базе.

Отсутствие неявных приведений и скрытых эффектов

Rust решительно отказывается от:

  • неявного приведения между целыми типами (i32u64);
  • неявного преобразования 0false, ""false;
  • неявного копирования (deep/shallow copy) сложных типов;
  • неявных конструкторов/деструкторов (в стиле C++);
  • скрытых аллокаций (например, при конкатенации String в цикле).

Всё, что может повлечь за собой:

  • аллокацию памяти,

  • копирование данных,

  • изменение состояния внешней переменной, — должно быть выражено явно. Например:

  • s.push_str("x") — изменяет s, но не создаёт новую строку;

  • s + "x" — создаёт новую String, требует владения s;

  • &s + "x" — ошибка: нельзя сложить &str и &str без аллокации;

  • format!("{}x", s) — аллокация, но явная.

Это инструмент предсказуемости. Разработчик всегда видит, где происходит:

  • передача владения (move);
  • заимствование (&);
  • аллокация (String::from, vec!);
  • копирование (clone(), copy-типы).

Макросы: гигиеничные, типобезопасные, компиляционные

println!, vec!, format!, dbg!, #[derive(Debug)] — всё это макросы, но не C-препроцессорные текстовые подстановки. Rust использует процедуральные и декларативные макросы, работающие на AST-уровне (после парсинга, до типизации). Они:

  • гигиеничны: не захватывают переменные извне;
  • типобезопасны: не компилируются, если переданы аргументы неверного типа;
  • могут генерировать код, адаптивный к контексту (например, vec![1, 2, 3] создаёт Vec<i32>, а vec!["a", "b"]Vec<&str>).

Макросы — расширение языка, одобренное компилятором. Например, serde генерирует сериализаторы/десериализаторы во время компиляции, без рантайм-рефлексии — и генерируемый код типизирован и проверен.


Модель владения

Часто говорят: «Rust сложен из-за borrow checker». На деле, borrow checker — это реализация более глубокой идеи: линейных ресурсов.

Владение как контракт

Каждое значение имеет:

  • одного владельца (владение передаётся при присваивании и передаче в функцию);
  • нуль или более заимствований, но с двумя взаимоисключающими режимами:
    • shared borrow (&T) — можно читать, но не писать; сколько угодно одновременно;
    • exclusive borrow (&mut T) — можно читать и писать; строго одна в области видимости.

Эти правила доказываются статически. Компилятор строит граф зависимостей между ссылками и значениями и проверяет его на наличие:

  • циклов (для &mut);
  • пересечений &mut и &;
  • ссылок, «выходящих» за пределы данных.

Результат — отсутствие гонок данных (Данные races) в безопасном (safe) Rust. Это формально подтверждено: в 2019 году Aaron Turon и др. доказали, что borrow checker исключает Данные races в рамках Rust memory model.

Время жизни

Аннотации 'a в &'a T не являются «опциональными подсказками». Они — часть типа. Тип &T — это сокращение для &'_ T, где '_выведенное время жизни. Но когда ссылки появляются в:

  • аргументах функции,
  • возвращаемых значениях,
  • полях структур, — время жизни должно быть указано явно или выведено однозначно.

Пример:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}

Здесь 'aобщее время жизни входных строк, и функция гарантирует, что возвращаемая ссылка не «переживёт» ни один из аргументов. Это не магия — это интерфейсный контракт, проверяемый компилятором.

В отличие от C (где const char* longest(const char*, const char*) не гарантирует ничего о lifetime), Rust делает время жизни частью API.

Copy и Clone: явное разделение «лёгкого копирования» и «глубокого»

Типы в Rust делятся на:

  • Copy — копируются побитово при присваивании/передаче; не имеют деструктора (Drop); только стековые типы без указателей (например, i32, bool, (i32, f64));
  • !Copy — передаются по владению; копирование требует явного .clone().

Это дизайн-решение: разработчик выбирает, какие типы «дешёвые» (и могут копироваться неявно), а какие — «дорогие» (и требуют осознанного копирования). Например:

  • String — не Copy: владеет heap-буфером;
  • &str — не Copy, но Copy-подобен: &str — это fat pointer (адрес + длина), и он Copy;
  • Vec<T> — не Copy: владеет буфером памяти.

Таким образом, Rust не скрывает стоимость операций.


Безопасность

В Rust различают:

  • safe Rust — код, соответствующий всем правилам borrow checker’а, типизации, инициализации; гарантированно не содержит:
    • use-after-free,
    • double-free,
    • Данные races,
    • uninitialised reads,
    • integer overflow (в debug-режиме),
    • выход за границы массива (при использовании [], но не get()).
  • unsafe Rust — блоки unsafe { … }, где разрешены:
    • разыменование «сырых» указателей (*const T, *mut T);
    • вызов unsafe-функций (например, из FFI);
    • реализация unsafe trait;
    • мутация статических переменных.

unsafe не отключает borrow checker. Он лишь расширяет поверхность допустимого, но обязанность доказать безопасность ложится на программиста. При этом:

  • unsafe-блок должен быть минимальным;
  • он должен быть обёрнут в safe-интерфейс (например, Vec::push использует unsafe внутри, но представляет safe API);
  • экосистема придерживается принципа «unsafe in safe»: чем меньше unsafe, тем выше доверие.

Это делает Rust практичным для системного кода: критичные примитивы (аллокаторы, атомики, FFI) могут быть реализованы с unsafe, но потребительский код остаётся 100% safe.


Инструментарий

Rust не просто язык — это платформа разработки:

  • rustc — компилятор с детальными диагностиками (включая подсказки вида «попробуйте добавить mut здесь»);
  • Cargo — система сборки, управления зависимостями, тестирования, документирования;
  • rustup — менеджер инструментария с поддержкой nightly/stable/beta, target-триплетов, компонентов (rust-src, rustfmt, clippy);
  • rustfmt — форматтер, обеспечивающий единый стиль по умолчанию;
  • clippy — линтер, выявляющий антипаттерны, неочевидные ошибки, неидиоматичный код;
  • miri — интерпретатор MIR (Mid-level IR) для динамической проверки UB (undefined behavior) в safe-коде.

Эти инструменты не «дополнения» — они встроены в workflow. Например:

cargo new project
cd project
cargo build # сборка
cargo run # запуск
cargo test # unit/integration-тесты
cargo doc --open # генерация и просмотр документации
cargo clippy # статический анализ
cargo fmt # форматирование

Это создаёт единый стандарт качества даже в распределённых командах.


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).